feat: add explicit OpenCode host import and repo relink#176
feat: add explicit OpenCode host import and repo relink#176itz4blitz wants to merge 2 commits intochriswritescode-dev:mainfrom
Conversation
There was a problem hiding this comment.
Pull request overview
Adds an explicit, repeatable OpenCode host import workflow (status + manual sync) and uses imported session directories to automatically relink repositories so existing chats can reconnect, with corresponding UI surfaced in Settings.
Changes:
- Extracts OpenCode host config/state import into a reusable backend service and reuses it for startup + a new manual import API.
- Adds repo relink logic that maps imported session directories to nearest git repo roots and registers them.
- Updates the Settings UI to show import detection status and relink results, plus adds/updates supporting API types and tests.
Reviewed changes
Copilot reviewed 10 out of 10 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| frontend/src/components/settings/OpenCodeConfigManager.tsx | Adds Settings card + actions to fetch import status and trigger host import, showing relink results. |
| frontend/src/api/types/settings.ts | Introduces OpenCodeImportStatus and SyncOpenCodeImportResponse types for the new import endpoints. |
| frontend/src/api/settings.ts | Adds client functions for GET /api/settings/opencode-import/status and POST /api/settings/opencode-import. |
| backend/src/services/repo.ts | Adds relinkReposFromSessionDirectories and helper to find nearest git repo root. |
| backend/src/services/opencode-import.ts | New service to detect/import host config + snapshot state DB + read imported session directories. |
| backend/src/routes/settings.ts | Adds OpenCode import status + sync endpoints and wires relink + restart into the workflow. |
| backend/src/index.ts | Replaces bootstrap import logic with calls into the new opencode-import service; updates settings route wiring. |
| backend/test/services/repo.test.ts | Adds coverage for relinking session directories to repo roots. |
| backend/test/services/opencode-import.test.ts | Adds coverage for import detection, import execution, and reading session directories. |
| backend/test/routes/settings.test.ts | Adds coverage for new import routes and updates route creation to pass git auth service. |
| configImported = await importOpenCodeConfigFromSource(options.db, userId, status.configSourcePath, status.workspaceConfigPath) | ||
| } | ||
|
|
||
| if (status.stateSourcePath && ((options.overwriteState ?? true) || !status.workspaceStateExists)) { |
There was a problem hiding this comment.
stateImported is set to true after importOpenCodeStateDirectory(...) even if the source directory is missing opencode.db (in which case importOpenCodeStateDirectory returns early and the workspace DB is not actually imported). Consider making importOpenCodeStateDirectory return a boolean (or throwing) so stateImported accurately reflects whether a usable DB snapshot was created, and so callers can surface a clear error.
| if (status.stateSourcePath && ((options.overwriteState ?? true) || !status.workspaceStateExists)) { | |
| if (status.stateSourcePath && ((options.overwriteState ?? true) || !status.workspaceStateExists)) { | |
| const sourceStateDbPath = path.join(status.stateSourcePath, 'opencode.db') | |
| if (!await fileExists(sourceStateDbPath)) { | |
| throw new Error(`OpenCode state source directory "${status.stateSourcePath}" is missing required file "opencode.db"`) | |
| } |
| const status = await getOpenCodeImportStatus() | ||
| const userId = options.userId || 'default' | ||
| let configImported = false | ||
| let stateImported = false | ||
|
|
||
| if (status.configSourcePath) { | ||
| configImported = await importOpenCodeConfigFromSource(options.db, userId, status.configSourcePath, status.workspaceConfigPath) | ||
| } | ||
|
|
||
| if (status.stateSourcePath && ((options.overwriteState ?? true) || !status.workspaceStateExists)) { | ||
| await importOpenCodeStateDirectory(status.stateSourcePath, status.workspaceStatePath) | ||
| stateImported = true | ||
| } | ||
|
|
||
| return { | ||
| ...status, | ||
| configImported, | ||
| stateImported, | ||
| } |
There was a problem hiding this comment.
syncOpenCodeImport returns the OpenCodeImportStatus captured before performing imports (...status). After a successful state import, workspaceStateExists in the response can be stale/misleading. Recompute workspaceStateExists (or re-run getOpenCodeImportStatus) after import so the returned status reflects the post-import workspace state.
| for (const directory of directories) { | ||
| const normalizedDirectory = normalizeInputPath(directory) | ||
| if (!normalizedDirectory) { | ||
| continue | ||
| } | ||
|
|
||
| const repoRoot = await findGitRepoRoot(normalizedDirectory, env) | ||
| if (!repoRoot) { | ||
| continue | ||
| } | ||
|
|
||
| uniqueRepoRoots.add(repoRoot) | ||
| } | ||
|
|
||
| const repos: Repo[] = [] | ||
| let relinkedCount = 0 | ||
| let existingCount = 0 | ||
|
|
||
| for (const repoRoot of Array.from(uniqueRepoRoots).sort((left, right) => left.localeCompare(right))) { | ||
| try { | ||
| const result = await registerExistingLocalRepo(database, gitAuthService, repoRoot) | ||
| repos.push(result.repo) | ||
| if (result.existed) { | ||
| existingCount += 1 | ||
| } else { | ||
| relinkedCount += 1 | ||
| } | ||
| } catch (error: unknown) { | ||
| errors.push({ | ||
| path: repoRoot, | ||
| error: getErrorMessage(error), | ||
| }) | ||
| } | ||
| } | ||
|
|
||
| return { | ||
| repos, | ||
| relinkedCount, | ||
| existingCount, | ||
| skippedCount: Math.max(0, directories.length - uniqueRepoRoots.size), | ||
| errors, | ||
| } |
There was a problem hiding this comment.
skippedCount is computed as directories.length - uniqueRepoRoots.size, which counts duplicate session directories from the same repo root as “skipped”. The UI text and PR description imply this represents “non-repo session paths”, so the metric can be inaccurate. Track non-repo/invalid paths separately from duplicates (or adjust naming/response fields) so the summary matches actual behavior.
| const result = await syncOpenCodeImportMutation.mutateAsync() | ||
| const importedParts = [result.configImported && 'config', result.stateImported && 'state'] | ||
| .filter(Boolean) | ||
| .join(' and ') | ||
| const relinkSummary = result.relinkedRepos | ||
| ? ` Linked ${result.relinkedRepos.relinkedCount} repos and matched ${result.relinkedRepos.existingCount} existing repos.` | ||
| : '' | ||
| showToast.success(`Imported existing OpenCode ${importedParts || 'data'} and restarted the server.${relinkSummary}`, { id: 'opencode-import' }) | ||
| } catch (error) { | ||
| showToast.error(getApiErrorMessage(error, 'Failed to import existing OpenCode host data'), { id: 'opencode-import' }) | ||
| } |
There was a problem hiding this comment.
The try block inside the onClick handler is mis-indented (the const result / const importedParts lines are not aligned). This is likely to fail formatting/lint rules and makes the handler harder to read—please run the formatter or fix indentation.
| const result = await syncOpenCodeImportMutation.mutateAsync() | |
| const importedParts = [result.configImported && 'config', result.stateImported && 'state'] | |
| .filter(Boolean) | |
| .join(' and ') | |
| const relinkSummary = result.relinkedRepos | |
| ? ` Linked ${result.relinkedRepos.relinkedCount} repos and matched ${result.relinkedRepos.existingCount} existing repos.` | |
| : '' | |
| showToast.success(`Imported existing OpenCode ${importedParts || 'data'} and restarted the server.${relinkSummary}`, { id: 'opencode-import' }) | |
| } catch (error) { | |
| showToast.error(getApiErrorMessage(error, 'Failed to import existing OpenCode host data'), { id: 'opencode-import' }) | |
| } | |
| const result = await syncOpenCodeImportMutation.mutateAsync() | |
| const importedParts = [result.configImported && 'config', result.stateImported && 'state'] | |
| .filter(Boolean) | |
| .join(' and ') | |
| const relinkSummary = result.relinkedRepos | |
| ? ` Linked ${result.relinkedRepos.relinkedCount} repos and matched ${result.relinkedRepos.existingCount} existing repos.` | |
| : '' | |
| showToast.success(`Imported existing OpenCode ${importedParts || 'data'} and restarted the server.${relinkSummary}`, { id: 'opencode-import' }) | |
| } catch (error) { | |
| showToast.error(getApiErrorMessage(error, 'Failed to import existing OpenCode host data'), { id: 'opencode-import' }) | |
| } |
| <div className="rounded-lg border border-border p-3"> | ||
| <p className="font-medium">Last Relink Result</p> | ||
| <p className="mt-1 text-muted-foreground"> | ||
| Linked {syncOpenCodeImportMutation.data.relinkedRepos.relinkedCount} repos, matched {syncOpenCodeImportMutation.data.relinkedRepos.existingCount} existing repos, skipped {syncOpenCodeImportMutation.data.relinkedRepos.skippedCount} non-repo session paths. |
There was a problem hiding this comment.
The UI message says skippedCount is "non-repo session paths", but the backend currently counts duplicates (multiple session directories resolving to the same repo root) as skipped as well. Either adjust the backend metric or update this label so it matches what is being counted.
| Linked {syncOpenCodeImportMutation.data.relinkedRepos.relinkedCount} repos, matched {syncOpenCodeImportMutation.data.relinkedRepos.existingCount} existing repos, skipped {syncOpenCodeImportMutation.data.relinkedRepos.skippedCount} non-repo session paths. | |
| Linked {syncOpenCodeImportMutation.data.relinkedRepos.relinkedCount} repos, matched {syncOpenCodeImportMutation.data.relinkedRepos.existingCount} existing repos, skipped {syncOpenCodeImportMutation.data.relinkedRepos.skippedCount} duplicate or non-repo session paths. |
| const MockSQLiteDatabase = SQLiteDatabase as unknown as ReturnType<typeof vi.fn> | ||
|
|
||
| describe('opencode-import service', () => { | ||
| const mockDb = {} as any |
There was a problem hiding this comment.
Avoid any in tests when possible. Here mockDb can be typed as unknown as Database (or never) to keep strict typing and match the style used in other tests in this repo.
| const mockDb = {} as any | |
| const mockDb = {} as unknown as SQLiteDatabase |
Summary
Why
OpenCode Manager already had the building blocks for reconnecting existing sessions, but the actual host import path was still mostly a first-run bootstrap behavior tied to empty workspace state.
That made Docker and existing-host setups fragile: if the initial import window was missed or volumes already existed, users had to manually bind paths, rediscover repos, and guess why old sessions were not appearing.
This change turns that into an explicit, repeatable workflow from Settings and uses imported session directories to relink the right repositories automatically.
What Changed
backend/src/services/opencode-import.tsGET /api/settings/opencode-import/statusandPOST /api/settings/opencode-importsession.directorypaths to nearest git repo roots and registers them via the existingsourcePathflowValidation
pnpm --filter backend exec vitest test/routes/settings.test.ts test/services/opencode-import.test.ts test/services/repo.test.tspnpm testpnpm lintpnpm buildNotes